2

The Problem

There is a pattern that I find myself to be frequently using, so I'd like to dry it up. I have stuff like this:

class InfoGatherer
  def foo
    true
  end

  def people
    unless @people
      @people = # Long and complex calculation (using foo)
    end
    @people
  end
end

I'd like to dry this up to look like this:

class InfoGatherer
  extend AttrCalculator

  def foo
    true
  end

  attr_calculator(:people) { # Long and complex calculation (using foo) }
end

To accomplish this, I defined a module AttrCalculator to extend into InfoGatherer. Here's what I tried:

module AttrCalculator
  def attr_calculator(variable_name_symbol)
    variable_name = "@#{variable_name_symbol}"

    define_method variable_name_symbol do
      unless instance_variable_defined?(variable_name)
        instance_variable_set(variable_name, block.call)
      end
      instance_variable_get(variable_name)
    end

  end
end

Unfortunately, when I try something as simple as InfoGatherer.new.people, I get:

NameError: undefined local variable or method `foo' for InfoGatherer:Class

Well, that's odd. Why is block running in the scope of InfoGatherer:Class, rather than its instance InfoGatherer.new?

The Research

I know I can't use yield, because that would try to catch the wrong block, as seen here. I attempted to use self.instance_exec(block) in the place of block.call above, but then I received a new error:

LocalJumpError: no block given

Huh? I see the same error in this SO question, but I'm already using bracket notation, so the answers there don't seem to apply.

I also tried to use class_eval, but I'm not sure how to call block inside of a string. This certainly doesn't work:

class_eval("
  def #{variable_name_symbol}
    unless #{variable_name}
      #{variable_name} = #{block.call}
    end
    #{variable_name}
  end
")
Community
  • 1
  • 1
Simon Lepkin
  • 1,021
  • 1
  • 13
  • 25

3 Answers3

4

That use case is called memoization. It can be done easily like:

def people
  @people ||= # Long and complex calculation (using foo)
end

You shouldn't go into the mess like you are.

sawa
  • 165,429
  • 45
  • 277
  • 381
  • That's what I do when it's a short and simple calculation. :) But you're right; any complex calculation can be moved into a helper method, and easily placed on one line. I'm still curious about what the proper way to pass a block into `define_method` is, though. Even if my provided use-case isn't a solid one. – Simon Lepkin Jan 10 '15 at 03:30
  • 4
    It does not have to be in one line. If you have a long chunk of code, you can do `||= begin ... end`. – sawa Jan 10 '15 at 03:32
1

To expand on the last persons

def people(varariable = nil)
  @people ||= ComplexCalculation.new(variable).evaluate
end

class ComplexCalculation

  def initialize(variable)
    @variable = variable
  end

  def evaluate(variable)
    #stuff
  end

end

By extracting this class you are isolating that complexity and will have a much better experience.

Austio
  • 5,939
  • 20
  • 34
  • That's good to point that out as option, but it's just one of many, including `@people ||= my_method(variable)`. btw, I think you meant `def people(variable=nil)`. – Cary Swoveland Jan 10 '15 at 03:49
1

The problem was that, inside the define_method, self was surprisingly InfoGatherer, rather than an instance of InfoGatherer. So I was on the right track with self.instance_exec(block).

The working solution is self.instance_exec(&block) (note the ampersand). I guess the interpreter doesn't recognize that block is a block unless you label it as such? If anyone can explain this better than me, please do.

As a side note, this is not the best way to solve this particular problem. See @sawa's answer for a clean way to memoize complicated calculations.

Simon Lepkin
  • 1,021
  • 1
  • 13
  • 25