DEV Community

Discussion on: Explain Ruby's define_method like I'm five

Collapse
 
rhymes profile image
rhymes • Edited

Hi Tiffany!

define_method does what the name entails. Allows you to create a method (instance method) on the fly. Let's take a step back.

If you were a five year old I would probably use a videogame analogy: the powerups. You're Mario or Zelda or whatever and you are running through the game. You end up picking up a mushroom or a tool and you're (let's ignore the fact that these powerups are usually temporary) able to do something new that you weren't five seconds ago. Basically the videogame added, while you were already playing, a new skill to your player (and only yours if you were playing a multi player game). You can use this skill like if it were there from the beginning, even if you know it was not.

define_method is like one of those super powers in videogames: allows you to add functions (skills) to your object (a singular player).

A quick side note: most examples on the internet use define_method for a tangential (but still powerful) usage: shortening the amount of code you have to type. For example: let's say you have a Job object that can be in a few different states: created, queued, running, completed, failed and you want to ask the job if it's running. You might decide to use define_method to iterate over all the possible states (likely defined in constants or a hash) and create methods so you can do job.running?.

I think the true power of define_method is in the analogy with the videogame though, not just to let the developer write less code during the definition of the class.

Let's see some code, shall we?

Let's start from the side note, adding methods based on a series of states:

class Job
  STATES = [:queued, :running, :completed, :failed]

  attr_accessor :status

  STATES.each do |method_name|
    define_method "#{method_name}?" do
      status == method_name
    end
  end
end

job = Job.new
p "job.queued? #{job.queued?}"
job.status = :queued
p "job.queued? #{job.queued?}"

This prints:

"job.queued? false"
"job.queued? true"

Following the game analogy here we're still a little off, because if you look closely we defined the method inside the class Job which means that ANY job will have those methods.

Let's take it a step further:

class Player
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def walk
    p "#{name}: walking"
  end

  def run
    p "#{name}: running"
  end
end

mario = Player.new("mario")
luigi = Player.new("luigi")

mario.walk
luigi.run

This will print:

"mario: walking"
"luigi: running"

So, how we give a powerup to Mario but not to luigi? The standard library comes to our rescue:

class Player
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def walk
    p "#{name}: walking"
  end

  def run
    p "#{name}: running"
  end

  # this allows you to add any method to a single instance at runtime
  def create_method(name, &block)
    self.class.send(:define_method, name, &block)
  end
end

mario = Player.new("mario")
luigi = Player.new("luigi")

mario.walk
luigi.run

mario.create_method(:fly) do
  p "#{name}: flying like an eagle!"
end

mario.fly

This will print:

"mario: walking"
"luigi: running"
"mario: flying like an eagle!"

There's still an issue, if we were to inadvertantly ask Luigi to fly this would happen:

"luigi: fly like an eagle!"

Wait, what? The thing is that define_method operates on the class by default. What if we really want this to happen just for mario?

This is where I get lost in Ruby metaprogramming (I admit I never fully understood this syntax):

class Player
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def walk
    p "#{name}: walking"
  end

  def run
    p "#{name}: running"
  end

  def create_method(name, &block)
    (class << self; self; end).instance_eval do
      define_method(name, &block)
    end
  end
end

mario = Player.new("mario")
luigi = Player.new("luigi")

mario.walk
luigi.run

mario.create_method(:fly) do
  p "#{name}: fly like an eagle!"
end

mario.fly
luigi.fly

this will print:

"mario: walking"
"luigi: running"
"mario: fly like an eagle!"
Traceback (most recent call last):
t.rb:34:in `<main>': undefined method `fly' for #<Player:0x00007fa7c0950cf8 @name="luigi"> (NoMethodError)

As you can see only mario now has that method. Fortunately there's a clearer way to create this "method generator", define_singleton_method:

class Player
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def walk
    p "#{name}: walking"
  end

  def run
    p "#{name}: running"
  end

  def create_method(name, &block)
    self.define_singleton_method(name, block)
  end
end

mario = Player.new("mario")
luigi = Player.new("luigi")

mario.walk
luigi.run

mario.create_method(:fly) do
  p "#{name}: fly like an eagle!"
end

mario.fly
luigi.fly

Ruby metaprogramming is definitely a complicated part of the language. :-)

Collapse
 
tiffanywismer profile image
Tiffany Wismer

Thank you for this amazing, detailed response! It is complicated but you definitely made it much clearer. :)

Collapse
 
rhymes profile image
rhymes

You're welcome :)