DEV Community

Tiffany Wismer
Tiffany Wismer

Posted on

Explain Ruby's define_method like I'm five

Y'all I'm dying here. Can somebody please explain how this works?

Top comments (5)

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 :)

Collapse
 
rhymes profile image
rhymes

@mudasobwa thanks for the addition!

Do you agree how weird is that syntax though?

I have to call extend on an object but then suddenly have to create a new Module and put my method in it.

Ruby's metaprogramming syntax is quite obscure sometimes.

Compare it with Python's, which I find more explicit and clear in this case:

import types

class Player:
  def __init__(self, name):
    self.name = name

  def walk(self):
    print(f"{self.name}: walking")

  def run(self):
    print(f"{self.name}: running")

  def create_method(self, name, method):
    # set an attribute on this istance, with a name and the given method
    setattr(self, name, types.MethodType(method, self))


mario = Player("mario")
luigi = Player("luigi")

mario.walk()
luigi.run()

def fly(self):
  print(f"{self.name}: fly like an eagle!")
mario.create_method('fly', fly)

mario.fly()

luigi.fly()

the output:

mario: walking
luigi: running
mario: fly like an eagle!
Traceback (most recent call last):
  File "t.py", line 29, in <module>
    luigi.fly()
AttributeError: 'Player' object has no attribute 'fly'
 
rhymes profile image
rhymes • Edited

Having an anonymous module owning a method is a correct approach. It’s not a matter of taste, it’s plain right and clean.

I'm not saying it's wrong or bad, it's obviously right in the context of how Ruby works. I'm just saying it seems weird if you read it.

To add a method named fly to an object mario I have to extend with a method inside an anonymous module that's going to be "injected" inside mario.

In Python this reads: to add a method named fly to an object mario I have to attach it to mario.

This is what I meant with "I find more explicit and clear".

Any way you put it metaprogramming is super cool, in each language I come across of it :-)

Thanks for the reminder about Elixir, I need to get around it sooner or later.