[Ruby] [Learning Memo] Metaprogramming Ruby 2nd Edition: Chapter 3: Methods

4 minute read

Overview

A memo of learning contents that read Chapter 3 Method of Metaprogramming Ruby.

Rough summary at first

In terms of content, it provides a solution to the problem of duplicate code in method definitions.

  • There are two major approaches to the solution.
    1. Dynamic method (define_method)
    2. Ghost method (method_missing)
  • Use dynamic methods whenever possible, and ghost methods when you can’t help it.

Example

We will proceed based on a concrete example of “making a system that detects computer parts that cost more than $99”.

Duplicate code problem

How to improve such a method with a lot of code duplication…

# Computer class with lots of duplication
class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end

  def mouse
    info = @data_source.get_mouse_info(@id)
    price = @data_source.get_mouse_price(@id)
    result = "Mouse: #{info} ($#{price})"
    return "* #{result}" if price >= 100
    result
  end

  def cpu
    info = @data_source.get_cpu_info(@id)
    price = @data_source.get_cpu_price(@id)
    result = "Cpu: #{info} ($#{price})"
    return "* #{result}" if price >= 100
    result
  end

  def keyboard
    info = @data_source.get_keyboard_info(@id)
    price = @data_source.get_keyboard_price(@id)
    result = "Keyboard: #{info} ($#{price})"
    return "* #{result}" if price >= 100
    result
  end

  # ....
end

Solution using dynamic method

Dynamic dispatch

Use Object#send (obj.send(:my_method, arg)) instead of the dot notation (obj.my_method(arg)) normally used to call methods. By using send, the method name you want to call becomes an argument and the method name can be specified dynamically. Such a method is called dynamic dispatch.

# Refactoring using dynamic dispatch
class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end

  def mouse
    component :mouse
  end

  def cpu
    component :cpu
  end

  def keyboard
    component :keyboard
  end

  def component(name)
    info = @data_source.send "get_#{name}_info", @id
    price = @data_souce.send "get_#{name}_price", @id
    result = "#{name.capitalize}: #{info} ($#{price})"
    return "* #{result}" if price >= 100
    result
  end
end

Dynamic method

Method can be defined dynamically by using Module#define_method. You need to pass a method name and a block, and the block becomes the body of the method. Here, we want to call define_method in the Computer class definition, so it must be a class method.

Further refactoring with #define_method
class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end

  def self.define_component(name)
    define_method(name) do
      info = @data_source.send "get_#{name}_info", @id
      price = @data_source.send "get_#{name}_price", @id
      result = "#{name.capitalize}: #{info} ($#{price})"
      retrun "* #{result}" if price >= 100
      result
    end
  end

  define_component :mouse
  define_component :cpu
  define_component :keyboard
end

Then it can be used like this

obj = Computer.new(42, data_source)
obj.mouse # => "Wireless Touch"
obj.price # => 60

Further introspection

To further eliminate duplication, introspect (*) the data_source and expand it into the name of the component.

Introduce #data_source!
class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
    Pass the block to the list of methods called #get_xxx_info,
# Define a method whose name is a string (mouse, cpu, etc.) that matches the regular expression
    data_source.methods.grep(/^get_(.*)_info$/) {Computer.define_component $1}
  end

  def self.define_component(name)
    define_method(name) do
      info = @data_source.send "get_#{name}_info", @id
      price = @data_source.send "get_#{name}_price", @id
      result = "#{name.capitalize}: #{info} ($#{price})"
      retrun "* #{result}" if price >= 100
      result
    end
  end
end

Now, even if a component is added on the data_source side, it is possible to support the Computer class without messing with it.

(*) Introspection: Asking the object about language elements (variables, classes, methods, etc.)

Listen to "hoge".class # class
=> String
"hoge".methods.grep(/to_(.*)/) # Listen to methods starting with "to_"
=> [:to_c, :to_str, :to_sym, :to_s, :to_i, :to_f, :to_r, :to_json_raw, :to_json_raw_object, :to_json, :to_enum]

Solution using ghost method

method_missing

When calling a method that does not exist, BasicObject#method_missing is called. This is a common feeling.

class Lawyer; end
nick = Lawyer.new
nick.talk
=> NoMethodError: undefined method `talk' for #<Lawyer:0x00007f921c0f2958>

By overriding this method_missing, you can call a method that does not actually exist. Such a method that is processed by method_missing but has no corresponding method on the receiver side is called a ghost method.

class Computer
  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end

  def method_missing(name)
# If the corresponding method does not exist in @data_source, call method_missing of the parent class
    super if !@data_source.respond_to?("get_#{name}_info")

    If there is a # method, do the following
    info = @data_source.__send__("get_#{name}_info", @id)
    price = @data_source.__send__("get_#{name}_price", @id)
    result = "#{name.capitalize}: #{info} ($#{price})"
    return "* #{result}" if price >= 100
    result
  end

  def respond_to_missing?(method, include_private = false)
    @data_source.respond_to?("get_#{method}_info") || super
  end
end

method_missing bug is hard to crush

Example)

class Roulette
  def method_missing(name, *args)
    person = name.to_s.capitalize
    3.times do
      number = rand(10) + 1puts "#{number}..."
    end
    "#{person} got a #{number}" # An infinite loop occurs here. I wonder why?
  end
end

Blank slate

Another trap for method_missing

In the above Computer class, only the display method does not work properly.

my_computer = Computer.new(42, DS.new)
my_computer.display # => nil

why. => The display method is already defined in the inheriting Object class.

Object.instance_methods.grep /^d/
=> [:define_singleton_method, :display, :dup]

I intend to call Computer#display, but I can’t reach method_missing because Object#display is found.

To solve this, it is necessary to leave unnecessary methods deleted. A class with a minimum number of methods is called a blank slate.

As a method of implementing blank slate, a method of inheriting the BasicObject class and a method of deleting unnecessary methods are introduced.

Conclusion

Thus, ghost methods run the risk of containing useful but hard-to-find bugs. And the following conclusions.

“Use dynamic methods whenever possible, and use ghost methods when it’s not possible.”

I’m going home and resting today.